문단 클릭 = 시작 위치 선택 · 재생은 우하단 버튼/P (재생 중 클릭 = 점프)

6주차. LangGraph Corrective-RAG와 ReAct 에이전트 — 워크플로우에서 에이전트로

출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉 2025) 6~7장의 실전 라이브코딩 강의(NewRunDev). 개념 backbone 은 book/Master_LangChain_LLM_Service 해당 장 참조.

원서는 구글 코랩 + OpenAI(gpt-4o 계열) + Tavily 로 LangGraph Corrective-RAG(6장)와 ReAct 에이전트(7장)를 만든다. 이 강의는 같은 두 실습을 Docker 컨테이너 안에서 LM Studio 로컬 추론 모델(gemma-4-12b-it)로 완주하고, 그 위에 개념 축 하나를 세운다 — 사람이 순서를 정하면 워크플로우, 모델이 제어권을 가지면 에이전트. 6장 앞부분의 LangGraph 맛보기(간단 채팅 그래프 등)는 건너뛰고 Corrective-RAG 와 ReAct 두 실습에 집중한다.


학습 목표

이 주차를 끝내면 다음을 할 수 있다.

  • 일반 RAG 에 자체 검증 단계를 더한 Corrective-RAG 를 LangGraph 상태 그래프로 조립하고, 노드가 "맵을 받아 맵을 토하는 함수"라는 것을 코드 수준에서 설명하며 조건부 엣지로 분기·루프를 설계한다.
  • 추론(reasoning) 모델을 로컬 완성 모델로 쓸 때의 함정(max_tokens·프롬프트 과잉 생성)을 식별하고 대응한다.
  • create_retriever_tool@tool 데코레이터로 도구를 만들고, 커스텀 한글 ReAct 프롬프트로 도구 기반 에이전트를 구동한다.
  • 워크플로우와 에이전트의 차이를 "제어권" 기준으로 정의하고, ReAct 가 왜 초창기 패턴인지(프롬프트 프로토콜 vs API tools + 채팅 템플릿, 순차 vs 병렬 호출) 설명한다.

전체 흐름도

같은 주차 안에서 두 개의 실습이 "사람의 제어 → 모델의 제어" 순서로 이어진다.

[practice6-1: Corrective-RAG — 사람이 정한 순서(워크플로우)]

 구글 스타일 가이드 3종(웹) ──크롤링·청킹(250토큰)──> [ Chroma 346청크 ] ──> retriever
                                                                                │
 질문 ──> retrieve ──> grade_documents ──(전부 관련)──────────────> generate ──> 답변
                          │ (하나라도 무관: web_search="예")            ▲
                          ▼                                             │
                    transform_query(질문 재작성) ──> web_search(Tavily)─┘

[practice7-1: ReAct 에이전트 — 모델이 정하는 순서(에이전트)]

 일본/미국 ICT 정책 PDF 2종 ──> Chroma 검색기 2개 ──> 도구화(japan_ict / usa_ict)
                                                        + web_search(Tavily, @tool)
 질문 ──> [ reasoning(Thought) ] ──Action──> 도구 실행 ──Observation──> (다시 reasoning 으로)
                    └──Final Answer──> 종료

practice6-1 의 분기는 코드에 미리 박혀 있고(조건부 엣지), practice7-1 의 분기는 매 단계 LLM 이 판단한다(어떤 도구를, 몇 번, 어떤 입력으로). 실측 규모: 스타일 가이드 크롤링 3문서 → 346청크, ICT PDF 는 일본 114청크·미국 123청크.


0. 사전 필수 용어

  • LCEL / |(파이프)prompt | llm | parser 처럼 왼쪽 출력을 오른쪽 입력으로 흘려보내는 랭체인 체인 문법. 셸 파이프와 같은 발상이며 .invoke(입력) 으로 실행한다.
  • 구조화 출력(structured output) — LLM 응답을 자유 텍스트가 아니라 미리 정의한 스키마(Pydantic 모델)에 맞춰 강제로 받는 기능. llm.with_structured_output(클래스).
  • Corrective-RAG(자체교정 RAG) — 검색된 문서를 LLM 이 다시 채점하고, 무관하다고 판단되면 질문을 재작성해 웹 검색으로 보완한 뒤 답을 만드는 RAG 변형.
  • 상태(State) — LangGraph 실행 중 여러 노드가 공유·갱신하는 딕셔너리. TypedDict 로 "이 배낭에 어떤 짐이 들어가는지" 선언한다.
  • 노드(Node) — 상태를 받아 자신이 바꾼 부분만 딕셔너리로 반환하는 보통의 파이썬 함수. 강의 표현으로 "맵을 받아 맵을 토하는" 함수.
  • (조건부) 엣지(Edge) — 노드 간 연결. 조건부 엣지는 상태를 보고 다음 노드 이름(문자열)을 반환하는 라우터 함수로 정의한다.
  • 워크플로우 vs 에이전트 — 실행 순서를 사람이 미리 고정하면 워크플로우(n8n 류), 도구 호출·루프 지속·종료를 모델이 결정하면 에이전트. 이 강의의 핵심 구분선.
  • ReAct(Reasoning + Acting) — Thought(생각) → Action(도구 선택) → Observation(결과 관찰)을 Final Answer 가 나올 때까지 반복하는 에이전트 패턴.
  • 도구(Tool) — 에이전트가 호출할 수 있는 외부 기능을 이름+설명으로 감싼 것. LLM 은 코드가 아니라 설명 문구만 보고 도구를 고른다.
  • 리라이터 / 가드레일 — 사용자 입력을 목적에 맞게 고쳐 쓰는 전처리 체인. 가드레일을 입력단에 두면 리라이터, 출력단에 두면 필터가 된다.

1. 로컬 환경 구성 — 추론 모델을 그대로 쓰는 법 (practice6-1)

책은 코랩에서 OpenAI 키와 Tavily 키를 .env 에 넣고 시작한다. 강의는 같은 .env(Tavily 키만 실제 키)를 쓰되, 완성/임베딩 모델을 LM Studio 로컬로 바꾼다.

1.1 gemma-4-12b-it — 5주차와 반대 방향의 모델 선택

5주차 GraphRAG 는 구조화 출력이 추론 모델에서 깨져(content 가 빈 문자열) non-thinking 모델을 골라야 했다. 6주차는 추론 모델 gemma-4-12b-it 를 그대로 쓴다. 이 모델은 추론 텍스트를 reasoning_content 에 분리해 내보내고 본문 content 에는 <think> 가 섞이지 않아, 구조화 출력·문자열 체인 모두 별도 후처리 없이 동작하기 때문이다. 대신 다른 함정이 있다 — 답변 전에 추론 토큰을 먼저 소모하므로 max_tokens 를 낮게 잡으면 추론만 하다 잘려 빈 답변이 돌아온다.

from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

# .env 의 TAVILY_API_KEY 를 os.environ 에 채워 둔다 — 뒤의 TavilySearchResults 가 키 인자 없이 동작.
load_dotenv()

LMSTUDIO_BASE_URL = "http://host.docker.internal:12345/v1"  # 컨테이너에서 호스트 PC 의 LM Studio
LMSTUDIO_API_KEY  = "lm-studio"   # LM Studio 는 키를 검증하지 않으므로 더미

llm = ChatOpenAI(
    model="gemma-4-12b-it",       # 추론 모델 — content 에 think 가 안 섞여 후처리 불필요
    base_url=LMSTUDIO_BASE_URL,
    api_key=LMSTUDIO_API_KEY,
    temperature=0,                # 채점·재작성 같은 판정 작업이라 무작위성을 최소화
    # 추론 모델은 답변 전에 reasoning_content 를 먼저 소모한다.
    # 짧게 잡으면 추론만 하다 빈 답변으로 잘리므로 넉넉히 확보.
    max_tokens=2048,
)

책 vs 강의 — 책은 gpt-4o-mini 를 키 하나로 붙이면 끝. 강의는 "로컬 모델이면 된다"가 아니라 추론 토큰 예산까지 계산에 넣어야 한다는 것을 보여준다. LM Studio 인퍼런스 설정에서 thinking 을 아예 끌 수도 있는데, 코딩처럼 깊은 추론이 필요 없는 작업이면 끄는 쪽이 응답이 훨씬 빨라진다는 팁도 함께 나온다.

1.2 임베딩 — OpenAI 클래스를 로컬에 맞추는 두 옵션

embeddings = OpenAIEmbeddings(
    model="qwen3-embedding-8b:tr",
    base_url=LMSTUDIO_BASE_URL,
    api_key=LMSTUDIO_API_KEY,
    # tiktoken 이 로컬 모델명을 몰라 에러가 나므로 토큰 계산을 끄고 원문 그대로 전송.
    check_embedding_ctx_length=False,
    # 로컬 서버는 한 번에 너무 많은 텍스트를 받으면 연결이 끊기는 경우가 있어 8개씩 배치.
    chunk_size=8,
)

# 인덱싱 전에 두 모델이 실제로 응답하는지 먼저 확인 — 값싼 사전 검증.
print("LLM  :", llm.invoke("연결 테스트. 'ok'라고만 답하세요.").content)
print("EMBED:", len(embeddings.embed_query("연결 테스트")), "차원")   # → 4096

1.3 웹 검색은 Tavily — DuckDuckGo 에서 되돌아온 이유

처음에는 API 키가 필요 없는 DuckDuckGo 로 대체했으나, 컨테이너 안에서 검색 백엔드가 자주 타임아웃돼(TimeoutException) 재실행 안정성이 떨어져 책과 동일한 Tavily 로 되돌렸다. Tavily 는 검색 엔진(Bing·Google)에서 정식으로 데이터를 받아 제공하는 서비스라 품질이 좋고 — 직접 크롤링하면 소송감이다 — 가입만 하면(신용카드 불요) 월 1,000 크레딧이 무료라 실습에 충분하다.


2. 문서 인덱싱 — 크롤링·청킹·Chroma (practice6-1 §1)

책과 동일하게 구글 코드 스타일 가이드 3종(Python/Java/JS)을 크롤링해 벡터 저장소를 만든다. LLM 은 이 페이지들을 미리 모르고, 매번 전체 텍스트를 프롬프트에 넣으면 느리고 비싸므로, 미리 조각 내 벡터로 저장해 두고 질문과 의미가 비슷한 조각만 꺼내 쓴다.

from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma

urls = [
    "https://google.github.io/styleguide/pyguide.html",   # + javaguide.html, jsguide.html
]

# 페이지 1개가 Document 여러 개로 나뉠 수 있어 "리스트의 리스트"가 되므로 평탄화한다.
docs = [WebBaseLoader(url).load() for url in urls]
docs_list = [item for sublist in docs for item in sublist]

# from_tiktoken_encoder: 글자 수가 아니라 "토큰 수" 기준으로 자른다. Recursive 라는 이름대로
# 문단 → 문장 → 단어 순의 구분자를 차례로 시도해 문장이 중간에서 뚝 끊기는 것을 최대한 피한다.
text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    chunk_size=250, chunk_overlap=0
)
doc_splits = text_splitter.split_documents(docs_list)   # 3문서 → 346청크

# 이 한 줄 안에서 "346개 청크 → 로컬 임베딩 API 호출 → 저장"이 모두 일어난다.
vectorstore = Chroma.from_documents(
    documents=doc_splits,
    collection_name="rag-chroma-practice6",
    embedding=embeddings,
)
retriever = vectorstore.as_retriever()   # 이후 노드들은 이 검색기만 사용한다

Chroma 를 고른 기준 — 강의의 실전 감각: PDF 한 장, 컬렉션 하나 규모면 Chroma 가 가볍고 빠르고 편하다. 데이터 규모가 커지면 성능 한계가 명확하므로 pgvector(3주차)로 보내야 한다. "간편함(Chroma) vs 대용량(pgvector)"의 이분법.


3. 그래프의 부품 — 4개의 체인 (practice6-1 §2~5)

LangGraph 로 조립하기 전에, 각각 "질문 하나 → 답 하나"를 내는 독립 부품 4개를 먼저 만든다. 모두 프롬프트 | llm | 출력장치 라는 같은 패턴이고, 체인 끝에 무엇을 붙이느냐로 출력 형태가 결정된다.

3.1 문서 평가 — 구조화 출력으로 "예/아니오"를 강제

자유 텍스트로 물으면 "예입니다", "아니요, 관련 없습니다", "예 (다만 일부만...)" 처럼 형식이 매번 달라져 파싱 예외가 끝없이 생긴다. 그래서 스키마를 강제한다.

from pydantic import BaseModel, Field
from langchain_core.prompts import ChatPromptTemplate

class GradeDocuments(BaseModel):
    # Field 의 description 은 LLM 에게도 전달돼 "이 필드에 뭘 채울지" 힌트 역할을 겸한다.
    binary_score: str = Field(description="문서와 질문의 연관성 여부. (예 or 아니오)")

# 기존 llm 을 감싸 매 호출마다 GradeDocuments 타입 객체를 반환하도록 만든 새 체인.
# 내부적으로는 "이 스키마에 맞는 도구를 호출해 달라"고 서버에 요청하는 방식으로 동작한다.
structured_llm_grader = llm.with_structured_output(GradeDocuments)

system = "당신은 검색된 문서의 관련성을 평가하는 전문가입니다. 관련 여부를 '예' 또는 '아니오'로 표시해 주세요."
grade_prompt = ChatPromptTemplate.from_messages([
    ("system", system),
    ("human", "검색된 문서: \n\n {document} \n\n 사용자 질문: {question}"),
])

retrieval_grader = grade_prompt | structured_llm_grader
# retrieval_grader.invoke({...}) → GradeDocuments(binary_score='예') 처럼 타입 보장 객체

3.2 답변 생성 — 이번엔 순수 텍스트가 필요하므로 StrOutputParser

from langchain_core.output_parsers import StrOutputParser

# 시스템 지시: "제공된 문맥으로 답하고, 모르면 모른다고 말하고, 세 문장 이내" (환각 억제 + 간결성)
system = "당신은 질문에 답변하는 업무를 돕는 도우미입니다. 제공된 문맥을 바탕으로 답하고, 모르면 모른다고 말하세요. 세 문장을 넘지 마세요."
prompt = ChatPromptTemplate.from_messages([
    ("system", system),
    ("human", "질문: {question} \n문맥: {context} \n답변:"),
])

def format_docs(docs):
    # retriever 는 Document 객체 리스트를 주지만 {context} 에는 긴 문자열 하나가 들어가야 한다.
    # 각 조각의 순수 텍스트만 뽑아 빈 줄로 이어 붙여 하나의 문맥으로 합친다.
    return "\n\n".join(doc.page_content for doc in docs)

# 3.1 과 같은 llm 인데 체인 끝이 StrOutputParser 라서 이번엔 순수 문자열이 나온다
# — "체인 끝에 무엇을 붙이느냐"가 출력 형태를 결정한다는 대비.
rag_chain = prompt | llm | StrOutputParser()

3.3 질문 재작성 — 리라이터는 입력단 가드레일이다

"C++ 깔끔하게 짜고 싶다" 같은 구어체 질문은 검색엔진에 그대로 넣으면 결과가 부실하다. 재작성 체인이 "C++ 클린 코드 작성 원칙" 처럼 다듬는다. 강의는 이 패턴을 더 크게 본다 — 가드레일을 프롬프트 입력단에 달면 리라이터, 출력단에 달면 필터가 되고, 부적절한 요청을 거르는 리라이터 프롬프트가 실제 서비스 업체들의 영업 비밀이다.

system = """당신은 입력된 질문을 변형하여 웹 검색에 최적화된 형태로 만드는 질문 생성기입니다.
입력된 질문을 보고 그 이면에 있는 의미나 의도를 파악해 주세요.
재작성한 질문 한 줄만 출력하세요. 여러 대안이나 설명, 마크다운 서식은 절대 덧붙이지 마세요."""

re_write_prompt = ChatPromptTemplate.from_messages([
    ("system", system),
    ("human", "질문: \n\n {question} \n 더 나은 질문으로 바꿔주세요."),
])

question_rewriter = re_write_prompt | llm | StrOutputParser()
# invoke({"question": "C++ 깔끔하게 짜고 싶다"})
# → 'C++ 클린 코드 작성 원칙 및 가독성 높은 프로그래밍 스타일 가이드'

"한 줄만 출력" 지시가 붙은 이유 (책은 못 만나는 함정) — 책의 원래 프롬프트는 GPT-4o-mini 기준으로 짧은 재작성 한 줄을 돌려주지만, gemma-4-12b-it 는 같은 지시에 "검색 목적별 5가지 질문 제안" 같은 1,000자 넘는 마크다운 가이드를 생성했다. 이걸 그대로 Tavily 검색어로 보내면 400 Bad Request 가 터진다. 같은 프롬프트라도 모델이 바뀌면 실제 반환값을 테스트 셀로 확인해야 한다.

웹 검색 질의는 짧아야 하는 검색론적 이유 — BM25 같은 역색인 검색은 키워드가 5개를 넘어가면 역색인이 변별력을 잃어 일반 TF-IDF 검색과 다를 게 없어진다. 재작성의 목표는 "장황한 사연"이 아니라 "밀도 높은 짧은 질의"다.

3.4 웹 검색 도구 — Tavily

from langchain_community.tools.tavily_search import TavilySearchResults

web_search_tool = TavilySearchResults(k=3)   # 상위 3건만 — generate 컨텍스트가 넘치지 않게
# invoke({"query": ...}) → [{"title":..., "url":..., "content":...}, ...] 딕셔너리 리스트

4. LangGraph 조립 — 상태·노드·엣지 (practice6-1 §6~8)

여기부터 LangGraph 다. 위 4개 부품을 하나의 순서도(그래프)로 엮어 "검색 → 평가 → (필요하면 재작성 → 웹 검색 →) 생성"을 자동으로 이어 실행한다. 강의의 요약: 랭체인은 기능 함수, 랭그래프는 그 함수들로 플로우차트를 그리는 도구. 플로우차트를 잘 그리는 사람이 LangGraph 를 잘 쓴다.

4.1 상태 — 그래프가 들고 다니는 공용 배낭

from typing import List
from typing_extensions import TypedDict
from langchain_core.documents import Document

# TypedDict 는 실행 시엔 일반 dict 와 같지만 "어떤 키에 어떤 타입이 들어가는지"를 선언한다.
# 노드끼리는 이 맵을 돌려 쓰는 것으로만 통신한다 — 정적 구조체가 아니라 키에 대한 약속.
class GraphState(TypedDict):
    question: str               # 사용자의 질문 (재작성되면 덮어써진다)
    generation: str             # LLM 이 생성한 최종 답변
    web_search: str             # 웹 검색 필요 여부 ("예"/"아니오") — grade 가 채우고 라우터가 읽는 신호
    documents: List[Document]   # retrieve 가 채우고, grade 가 걸러내고, web_search 가 보강하는 문서 리스트

4.2 노드 5개 — 기존 체인을 상태 입출력으로 얇게 감싼 함수

모든 노드는 같은 규칙을 따른다: 상태를 받아, 필요한 값만 꺼내 쓰고, 자신이 바꾼 값만 담은 작은 딕셔너리를 반환한다. LangGraph 가 반환값을 기존 상태에 자동 병합하므로 배낭 전체를 새로 만들 필요가 없다.

def retrieve(state):
    """문서를 검색합니다"""
    print("---검색---")
    documents = retriever.invoke(state["question"])   # 2절의 검색기를 그대로 호출
    # generation·web_search 는 이 노드가 손대지 않으므로 반환에서 빼도 기존 값이 유지된다.
    return {"documents": documents, "question": state["question"]}

def grade_documents(state):
    """검색된 문서가 질문과 연관이 있는지 평가합니다"""
    print("---문서와 질문의 연관성 평가---")
    question = state["question"]
    filtered_docs = []
    web_search = "아니오"
    for d in state["documents"]:
        # 문서 조각 하나씩 채점 — 문서 개수만큼 LLM 을 반복 호출하는 비용을 치르고
        # "무관한 문서가 답변에 섞이는" 품질 사고를 막는 트레이드다.
        score = retrieval_grader.invoke({"question": question, "document": d.page_content})
        if score.binary_score == "예":
            filtered_docs.append(d)
        else:
            # break 가 아니라 continue: 하나라도 무관하면 web_search 는 "예"로 확정되지만,
            # 나머지 관련 문서들도 끝까지 모아야 최종 답변의 재료가 되기 때문.
            web_search = "예"
            continue
    return {"documents": filtered_docs, "question": question, "web_search": web_search}

def transform_query(state):
    """질문을 더 적합한 형태로 변환합니다"""
    print("---질문 변환---")
    better_question = question_rewriter.invoke({"question": state["question"]})
    # 반환값의 question 에 재작성본을 넣으므로 이후 노드는 새 질문을 보게 된다
    # — 상태 값이 그래프 진행 중에 실제로 "바뀌는" 지점.
    return {"documents": state["documents"], "question": better_question}

def web_search(state):
    """웹 검색을 수행합니다 (Tavily)"""
    print("---웹 검색---")
    question = state["question"]   # transform_query 를 거쳤다면 재작성된 질문
    documents = state["documents"]
    docs = web_search_tool.invoke({"query": question})
    # Tavily 는 API 오류 시 예외 대신 오류 메시지 "문자열"을 반환한다
    # (예: 검색어가 너무 길어 400). 타입을 확인해 빈 결과로 강등 처리한다.
    if isinstance(docs, str):
        docs = []
    web_results = Document(page_content="\n".join(d["content"] for d in docs))
    # 대체가 아니라 append: 필터에서 살아남은 문서 + 웹 결과가 함께 generate 로 간다.
    # 필터가 10개를 전부 걸러 빈손이 될 수도 있으므로, 웹 결과는 최소 재료를 보장하는 안전망.
    documents.append(web_results)
    return {"documents": documents, "question": question}

마지막 generate 노드는 3.2절의 rag_chainformat_docs(documents)question 으로 호출해 generation 을 채우는 얇은 포장일 뿐이다 — 5개 노드 모두 "2~5절에서 만든 부품을 상태 입출력에 맞춰 감싼 것"이며 새 로직은 없다.

4.3 조건부 엣지 — 라우터는 맵이 아니라 문자열을 반환한다

고정 엣지(add_edge(A, B))만으로는 "평가 결과에 따라 다음 노드가 달라진다"를 표현할 수 없다. 조건부 엣지의 라우터 함수는 입력은 노드와 똑같이 상태(맵)를 받지만, 반환은 다음 노드 이름 문자열이다. 매핑 딕셔너리에 항목을 늘리면 스위치문처럼 여러 갈래로도 분기한다.

def decide_to_generate(state):
    """답변을 생성할지, 질문을 재작성할지 결정합니다"""
    if state["web_search"] == "예":
        return "transform_query"   # 하나라도 무관 → 재작성·웹 검색 경로
    return "generate"              # 전부 관련 → 바로 생성

4.4 조립·컴파일·실행

from langgraph.graph import END, StateGraph, START

workflow = StateGraph(GraphState)     # 이 그래프가 다루는 상태의 "모양"을 지정

workflow.add_node("retrieve", retrieve)               # "그래프 안 이름" → "실행할 함수"
workflow.add_node("grade_documents", grade_documents)
workflow.add_node("generate", generate)
workflow.add_node("transform_query", transform_query)
workflow.add_node("web_search_node", web_search)

workflow.add_edge(START, "retrieve")                  # 시작하면 항상 검색부터
workflow.add_edge("retrieve", "grade_documents")
workflow.add_conditional_edges(
    "grade_documents",
    decide_to_generate,                # 라우터가 돌려준 문자열을
    {"transform_query": "transform_query", "generate": "generate"},   # 실제 노드로 매핑
)
workflow.add_edge("transform_query", "web_search_node")
workflow.add_edge("web_search_node", "generate")
workflow.add_edge("generate", END)

app = workflow.compile()   # 설계도를 실행 가능한 객체로 빌드

실행은 app.invoke(끝까지 돌고 최종 상태만) 또는 app.stream(노드가 끝날 때마다 결과를 흘려보내는 제너레이터)으로 한다. 노드별 진행을 눈으로 보려면 stream 이 답답하지 않다.

inputs = {"question": "구글의 코드 작성 가이드"}
for output in app.stream(inputs):       # 매 반복 = {"방금 실행된 노드 이름": 그 노드의 반환값}
    for key, value in output.items():
        print(f"Node '{key}':")
print(value["generation"])   # 항상 generate 로 끝나므로 루프 뒤 value 가 곧 최종 답변
  • 관련성 높은 질문("구글의 코드 작성 가이드") — 경로: retrieve → grade_documents(4건 전부 "예") → generate. 삽질 없이 3개 노드로 종료.
  • 관련성 낮은 질문("C++ 깔끔하게 짜고 싶다") — 인덱싱에 없는 C++ 주제라 grade 에서 2건이 "아니오". 경로: transform_query → web_search_node → generate 로 우회해 웹 검색으로 보완된 C++ 클린 코드 원칙이 나온다.

루프로의 확장 — 이 그래프의 조건 판단은 "예/아니오" 하나였지만, 상태에 카운트를 넣으면 "3번째 실패면 포기, 아니면 웹 검색으로 재도전" 같은 루프도 같은 조건부 엣지 문법으로 구현된다. 결국 LangGraph 는 함수형 파이프라인이고, 분기·루프 모두 "상태를 보고 노드 이름을 반환"하는 라우터 하나로 표현된다.


5. ReAct 에이전트 — 모델이 제어권을 갖는다 (practice7-1)

5.1 워크플로우 vs 에이전트 — 이 강의의 한 줄 정의

practice6-1 은 사람이 미리 정한 순서(검색 → 평가 → ... )를 그래프로 고정했다. 이것은 워크플로우다(n8n 같은 도구들의 세계). 에이전트는 모델이 제어권을 갖는 것 — 도구를 호출할지 말지, 루프를 계속 돌지 최종 답변으로 빠질지를 매 단계 LLM 이 결정한다. 의사코드로는 이 while 루프가 전부다.

# 에이전트 루프의 본질 (강의 칠판 의사코드)
while True:
    response = model.send(context)
    if response.is_tool_call:
        context += execute(response.tool_call)   # 도구 실행 결과를 컨텍스트에 누적하고 계속
    else:
        break                                     # 모델이 "최종 답변"을 선택하면 루프 탈출

모델이 제어권을 더 많이, 더 오래 유지할수록(서브 에이전트·작업 큐·세션 생성 등) 창발적 가치가 커진다 — 현대 바이브 코딩 도구들이 그 연장선이다. 이 실습은 그 가장 기초 형태인 "도구 호출 기반 루프"를 만든다.

5.2 PDF 2종 → 재사용 가능한 검색기 함수

책과 동일하게 일본/미국 ICT 정책 보고서 PDF 를 받아 각각 벡터 DB 로 만든다. practice6-1 의 "로딩 → 청킹 → Chroma" 흐름과 같지만, 입력이 PDF 이고 여러 문서에 반복 사용하도록 함수로 감쌌다.

from langchain_community.document_loaders import PyMuPDFLoader

def create_pdf_retriever(pdf_path, persist_directory, embedding_model,
                         chunk_size=512, chunk_overlap=0):
    persist_path = Path(persist_directory)
    # 재실행 안전: 이미 만들어 둔 벡터 스토어가 있으면 PDF 재임베딩 대신 디스크에서 연다.
    # 로컬 임베딩은 돈은 안 들지만 시간이 걸리므로, 이 분기가 재실행 체감을 좌우한다.
    if persist_path.exists() and any(persist_path.iterdir()):
        vectorstore = Chroma(persist_directory=persist_directory,
                             embedding_function=embedding_model)
        return vectorstore.as_retriever()

    data = PyMuPDFLoader(pdf_path).load()   # 페이지 단위 Document 로 추출
    doc_splits = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
        chunk_size=chunk_size, chunk_overlap=chunk_overlap).split_documents(data)
    # persist_directory 를 지정했으므로 메모리뿐 아니라 디스크에도 저장된다.
    vectorstore = Chroma.from_documents(persist_directory=persist_directory,
                                        documents=doc_splits, embedding=embedding_model)
    return vectorstore.as_retriever()

retriever_japan = create_pdf_retriever("practice7/ict_japan_2024.pdf", "practice7/db_ict_policy_japan_2024", embd)  # 114청크
retriever_usa   = create_pdf_retriever("practice7/ict_usa_2024.pdf", "practice7/db_ict_policy_usa_2024", embd)      # 123청크

5.3 도구 3종 — 에이전트는 설명 문구만 보고 고른다

에이전트(LLM)는 도구의 실제 코드를 보지 못한다. 오직 이름과 설명만 읽고 "지금 어떤 도구가 적합한지" 고르므로, description 의 구체성이 에이전트 성능에 직결된다.

from langchain_classic.tools.retriever import create_retriever_tool
from langchain_core.tools import tool

# create_retriever_tool: 검색기를 도구로 감싸는 헬퍼. 인자 스키마는 retriever 에서
# 자동으로 끌어오므로 사람은 이름·설명 두 가지만 채우면 된다.
jp_engine = create_retriever_tool(
    retriever=retriever_japan,
    name="japan_ict",
    description="일본의 ICT 시장 동향 정보를 제공합니다. 일본 ICT와 관련된 질문은 해당 도구를 사용하세요.",
)
usa_engine = create_retriever_tool(retriever=retriever_usa, name="usa_ict",
    description="미국의 ICT 시장 동향 정보를 제공합니다. 미국 ICT와 관련된 질문은 해당 도구를 사용하세요.")

_tavily = TavilySearchResults(k=3)

@tool
def web_search(query: str) -> str:
    """일본/미국 ICT 정책 문서(japan_ict, usa_ict)에 없는 최신 정보나 일반적인 웹 검색이
    필요할 때 사용하세요."""
    # @tool 데코레이터의 변환 규칙: 함수 이름 → 도구 이름, 이 독스트링 → 도구 설명,
    # 인자 타입 힌트 → JSON 스키마. 즉 이 독스트링은 주석이 아니라 에이전트가 읽는 안내문이다.
    results = _tavily.invoke({"query": query})
    return "\n\n".join(r["content"] for r in results)

# 리스트 순서는 우선순위가 아니다 — 에이전트는 이름/설명으로 고른다.
tools = [jp_engine, usa_engine, web_search]

버전 참고 — 이 워크스페이스의 langchain 은 1.x 대라, 책이 쓰는 langchain.agents.create_react_agent/AgentExecutorcreate_retriever_toollangchain_classic 패키지로 이동했다. import 경로만 바꾸면 동작·프롬프트 형식은 책과 동일하다.

5.4 커스텀 ReAct 프롬프트와 실행 루프

책은 허브 기본 프롬프트 대신 한글 ReAct 프롬프트를 직접 쓴다. 자리표시자 4개가 핵심이다.

자리표시자 채워지는 값
{tools} 도구들의 이름+설명 나열 (에이전트가 "어떤 도구가 있는지" 읽는 부분)
{tool_names} 도구 이름 목록 — Action: 줄에 이 중 하나만 적으라는 제약
{input} 사용자의 원래 질문
{agent_scratchpad} 지금까지의 Thought/Action/Observation 기록 (반복마다 누적)
from langchain_core.prompts import PromptTemplate
from langchain_classic.agents import AgentExecutor, create_react_agent

template = """다음 질문에 최선을 다해 답하세요. 당신은 다음 도구들에 접근할 수 있습니다:

{tools}

다음 형식을 사용하세요:

Question: 답변해야 하는 입력 질문
Thought: 무엇을 할지 항상 생각하세요.
Action: 취해야 할 행동, [{tool_names}] 중 하나여야 합니다.
Action Input: 행동에 대한 입력값
Observation: 행동의 결과
... (이 사이클이 N번 반복될 수 있습니다)
Thought: 이제 최종 답변을 알겠습니다.
Final Answer: 원래 입력된 질문에 대한 최종 답변

(중략 — 원문에는 "문제를 분할하여 풀 것", "정보가 취합되면 사이클을 반복하지 말 것" 등 주의사항이 이어진다)

Question: {input}
Thought: {agent_scratchpad}"""

prompt = PromptTemplate.from_template(template)

# create_react_agent: "한 걸음"만 판단하는 두뇌 — scratchpad 를 보고 다음 도구 호출
# 또는 Final Answer 를 한 번 결정할 뿐, 스스로 루프를 돌지 않는다.
react_agent = create_react_agent(llm, tools=tools, prompt=prompt)

# AgentExecutor: 그 두뇌를 감싸 실제 반복을 도는 러너 — agent 호출 → 도구 실행 →
# 결과를 scratchpad 에 추가 → 다시 agent 호출... 을 Final Answer 까지 반복.
react_agent_executor = AgentExecutor(
    agent=react_agent,
    tools=tools,
    verbose=True,                # Thought/Action/Observation 전 과정을 로그로 출력
    handle_parsing_errors=True,  # 형식 위반 시 중단 대신 "형식이 잘못됐다"를 보여주고 재시도
    max_iterations=8,            # 로컬 모델의 변동성 대비 — 한도 도달 시 그때까지 결과로 마무리
)

한 번의 LLM 호출은 보통 Thought:Action:Action Input: 까지만 출력하고 멈춘다(stop sequence 가 Observation: 앞에서 생성을 끊는다). 그러면 랭체인이 그 도구를 코드로 실제 실행해 결과를 Observation: 으로 이어 붙이고 다시 LLM 을 호출한다 — 질문 하나에 LLM 이 여러 번 불리는 구조다.

5.5 실행 — 질의 3종으로 보는 에이전트의 판단

react_agent_executor.invoke({"input": 질문}) 이 루프를 실행하고 result["output"] 에 최종 답변이 담긴다. verbose 로그가 곧 "에이전트가 어떤 도구를 왜 골랐는지"의 학습 자료다.

  1. 한 도구로 끝나는 질문 — "한국과 미국의 ICT 기관 협력 사례": web_search 한 번 → Final Answer. 웹 검색 1회로 해결.
  2. 두 도구 비교 질문 — "미국과 일본의 ICT 주요 정책의 공통점과 차이점": usa_ict 로 미국 조사 → Thought("이제 일본을 조사해야 비교 가능") → japan_ict → 종합 답변. 도구 2개를 순차 호출해 비교 재료를 모았다.
  3. 복합 질문(분할) — "미국의 정부 기구·법령·국내 기업 진출 사례 각각 따로, 그리고 일본 AI 정책도": 에이전트가 질문을 두 덩어리로 스스로 나누고 usa_ict 2회 + japan_ict 1회, 총 3회 호출 후 종합. 프롬프트의 "문제를 분할하라" 지시를 모델이 실제로 따른 것 — 이 계획 수립을 전부 모델이 했다는 것이 에이전트의 실체다.

참고로 AgentExecutor 는 LangGraph 객체가 아니라 .get_graph() 로 그림을 뽑을 수 없어, 노트북은 같은 반복 구조를 흉내 낸 시각화 전용 미니 LangGraph(noop 노드들)로 "reasoning 을 중심으로 도구들과 왕복하는 고리" 모양을 그림으로 확인한다.

5.6 ReAct 는 어디까지 유효한가 — 프롬프트 프로토콜의 한계

강의가 마지막에 세게 짚는 부분이다. 이 ReAct 프롬프트는 도구 목록을 일반 시스템 프롬프트 텍스트로 밀어 넣는다. 그런데 현대 모델들은 도구를 특수 태그(예: <tools> 로 감싼 JSON 스키마) 형식으로 인식하도록 학습되어 있고, 그 변환은 API 서버가 모델별 Jinja 채팅 템플릿으로 수행한다(LM Studio 의 모델 탭에서 이 템플릿을 직접 볼 수 있다). 그래서:

  • API 요청의 tools 섹션으로 보내면 서버가 모델이 학습한 형식으로 번역해 도구 선택 제약(constraint)이 강하게 걸리고, ReAct 처럼 프롬프트 텍스트로 흉내 내면 학습된 형식을 우회해 제약이 약해 선택 품질이 떨어진다. 다만 후자는 모델별 특성을 안 타는 범용성이 장점이라 교육용으로 적합하다.
  • 또 하나: ReAct 는 도구를 한 번에 하나씩 순차 호출하지만, 현대 모델들은 한 추론에서 필요한 도구 여러 개를 병렬로 호출한다. Thought/Action 사이클을 프롬프트로 강제하는 것 자체가 "프롬프트가 재산이던 초창기"의 유산이다.

책 vs 강의 — 책은 ReAct 를 "에이전트 만드는 법"으로 가르치고 끝낸다. 강의는 같은 코드를 돌리면서 "이건 원리 학습용 초창기 패턴이고, 실전은 API tools + 채팅 템플릿 + 병렬 호출"이라는 좌표를 함께 준다. 원리(수동 루프)를 알아야 현대 도구(자동 루프)를 디버깅할 수 있다는 취지다.


6. 책 대비 강의의 실전 선택 총정리

책(원서 6~7장) 강의(6주차 실습) 왜 이 선택인가
실행 환경 구글 코랩 Docker 컨테이너(파일 영속) 런타임 종료해도 벡터 DB·산출물 유지
완성 모델 OpenAI gpt-4o/gpt-4o-mini LM Studio gemma-4-12b-it(추론 모델) content 에 think 미혼입 → 후처리 없이 책 코드 재사용, 단 max_tokens 넉넉히
임베딩 OpenAI text-embedding-* qwen3-embedding-8b:tr + ctx 검사 off·chunk 8 tiktoken 미등록 모델명·로컬 서버 연결 안정성 대응
웹 검색 Tavily Tavily (DuckDuckGo 시도 후 회귀) 무키 검색은 타임아웃 잦음, Tavily 는 무료 크레딧+품질
재작성 프롬프트 1문장 지시 그대로 "한 줄만, 서식 금지" 지시 추가 gemma 가 1,000자 가이드 생성 → Tavily 400 오류
벡터 저장소 Chroma Chroma (동일) + pgvector 와의 사용처 구분 소규모 간편(Chroma) vs 대용량(pgvector)
에이전트 API langchain.agents langchain_classic (langchain 1.x 이동) 레거시 ReAct API 의 패키지 분리 대응
에이전트 안전장치 없음 max_iterations=8, handle_parsing_errors=True 로컬 모델의 루프 폭주·형식 위반 대비
도구 구성 japan_ict, usa_ict 2종 + web_search(Tavily) 3종 PDF 밖 최신 정보 질문에 대한 탈출구
개념 프레임 LangGraph·ReAct 사용법 워크플로우 vs 에이전트(제어권), 프롬프트 프로토콜의 한계 초창기 패턴과 현대 방식(tools 섹션·병렬 호출)의 좌표 제공

최신 동향 (검증 2026-07-31)

아래는 이 강의 노트북이 실제로 마주친 버전 현실(langchain 1.x)과 공식 발표에서 확인된 내용만 적는다. 시점에 따라 또 바뀔 수 있으므로 공식 문서를 함께 확인하라.

  • LangChain·LangGraph 1.0 정식 출시. LangGraph 는 1.0부터 durable execution(장기 실행·중단 복구)·메모리·human-in-the-loop 을 갖춘 에이전트 런타임으로 자리 잡았고, 이 강의가 쓰는 StateGraph 저수준 API 는 그대로 유지된다(파괴적 변경 없이 1.0 승격). (공식: https://www.langchain.com/blog/langchain-langgraph-1dot0)
  • 레거시 에이전트 API 는 create_agent 로 세대교체. 책·강의가 쓰는 create_react_agent/AgentExecutor 류는 deprecated 상태로 langchain-classic 패키지에 격리됐고, 신규 코드는 미들웨어 시스템을 갖춘 langchain.agents.create_agent 사용이 권장된다 — 이 노트북의 langchain_classic import 가 바로 그 흔적이다. (공식: https://reference.langchain.com/python/langgraph.prebuilt/chat_agent_executor/create_react_agent)
  • TavilySearchResultslangchain-tavily 패키지로 이동. LangChain 0.3.25에서 deprecated 됐고(노트북 실행 시 경고 출력 확인), 신규 코드는 pip install langchain-tavilyfrom langchain_tavily import TavilySearch 사용이 안내된다. langchain-community 자체가 유지보수 종료(sunset) 수순이라 커뮤니티 통합 전반이 독립 패키지로 옮겨가는 흐름의 일부다. (공식: https://pypi.org/project/langchain-tavily/)
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플   P 음성 버전 2026-07-29